This is something of an experiment with sentimental value.Late last century I worked in a business partnership and my first one-man-developed project started off as a text terminal more for interest than anything else initially.At the time all I had to work with was the Z80 processor and assembly language feeding a large pub monitor.
I spent weeks of coding developing the terminal which eventually grew into something much larger – a full advertising system with blocky graphics.Having spent the last couple of weeks working on coding with ChatGPT to program up ESP32 and an ST7789v display board for something completely different, yesterday for no good reason I decided it might be fun to make a little serial/WiFi terminal from the two and so dragged out one of my larger ESP32 WROOM-32 boards (plenty of RAM for scrolling buffer etc) and fastened the ESP32 up to the SPI interface on the ST7789v board.Pin usage and code provided.
This is an ESPHOME project – if you’re familiar with ESPHOME, WIFI secrets should be familiar to you… I have 7 potential SSIDs when moving around our home hence the 7 you’ll see in the code further down the page – they all have the same password.You may only have 1? The result? Well, it looks like I now have a simple 256-colour ANSI terminal able to take ANSI text input over WIFI or the USB serial (default 9600 baud) and with it’s own ESPHOME WebUI.At this point, claiming it is fully tested would be an overstretch because implementation as you’ll see below took a lot more time than I’d planned – but now I can fire in text via WiFi or serial (USB connector on the ESP32) from, for example, MOBAXTERM complete with ANSI clear screen and colour control if needed along with various other commands – with the default 8pt font, I get 40 characters a line and 39 lines with scrolling at the bottom.
Backspace works.Note: MISO on the display board is not needed.Feel free to take the header and main file, put them into your ESPHOME folder and build up the board (the ST7789v is NOT the touch version and costs only a few euros)… no guarantees but it seems to be fine up to now.
ST7789V LIVE MINI KIT ESP32 VCC ──────────── 3.3V GND ──────────── GND SCK ──────────── GPIO22 SDI/MOSI ──────────── GPIO21 CS ──────────── GPIO25 DC ──────────── GPIO16 RESET ──────────── GPIO17 LED ──────────── GPIO4 External serial: Terminal TX ────────── GPIO26 (ESP RX) Terminal RX ────────── GPIO27 (ESP TX) Terminal GND ───────── GND Here’s the YAML project file – ansi_terminal.yaml – you can change font and change WiFi information easily.substitutions: terminal_version: "0.94" terminal_welcome: "ESP32 ANSI terminal" name: "ansi-terminal" friendly_name: "ANSI Terminal" esphome: name: ${name} friendly_name: ${friendly_name} includes: - ansi_terminal.h libraries: - WiFi - Network esp32: board: esp32dev framework: type: arduino logger: baud_rate: 0 api: ota: - platform: esphome wifi: networks: - ssid: !secret wifi_ssid password: !secret wifi_password - ssid: !secret wifi_ssid2 password: !secret wifi_password - ssid: !secret wifi_ssid3 password: !secret wifi_password - ssid: !secret wifi_ssid4 password: !secret wifi_password - ssid: !secret wifi_ssid5 password: !secret wifi_password - ssid: !secret wifi_ssid6 password: !secret wifi_password - ssid: !secret wifi_ssid7 password: !secret wifi_password web_server: port: 80 version: 3 spi: clk_pin: GPIO22 mosi_pin: GPIO21 interface: hardware font: - file: "gfonts://Roboto Mono" id: terminal_font size: 8 bpp: 1 glyphsets: - GF_Latin_Core output: - platform: ledc pin: GPIO4 id: display_backlight_pwm frequency: 1000 Hz light: - platform: monochromatic output: display_backlight_pwm id: display_backlight name: "Display Backlight" restore_mode: ALWAYS_ON display: - platform: mipi_spi id: terminal_display model: ST7789V cs_pin: GPIO25 dc_pin: GPIO16 reset_pin: GPIO17 rotation: 0° color_order: bgr data_rate: 20MHz color_depth: 16 buffer_size: 12% update_interval: 250ms lambda: |- if (terminal_instance != nullptr) { terminal_instance->render(it); } select: - platform: template name: "Terminal Baud Rate" id: terminal_baud_select optimistic: true restore_value: true initial_option: "9600" options: - "1200" - "2400" - "4800" - "9600" - "19200" - "38400" - "57600" - "115200" - "230400" set_action: - lambda: |- if (terminal_instance != nullptr) { terminal_instance->set_baud((uint32_t) atoi(x.c_str())); } text_sensor: - platform: wifi_info ip_address: name: "Terminal IP Address" ssid: name: "Terminal WiFi SSID" - platform: template name: "Terminal Firmware Version" lambda: |- return {"${terminal_version}"}; update_interval: 1h interval: - interval: 5ms then: - lambda: |- if (terminal_instance == nullptr) { terminal_instance = new AnsiTerminal(id(terminal_display), id(terminal_font), "${terminal_version}"); terminal_instance->setup(); } terminal_instance->loop(); Here’s the header file – ansi_terminal.h – the C file is not so easy to tinker with.#pragma once #include "esphome.h" #include <WiFi.h> #include <Network.h> #include <HardwareSerial.h> #include <string.h> class AnsiTerminal : public Component { public: static constexpr uint8_t COLS = 48; static constexpr uint8_t ROWS = 39; static constexpr uint8_t CELL_W = 5; static constexpr uint8_t CELL_H = 8; static constexpr uint8_t ATTR_BOLD = 1; static constexpr uint8_t ATTR_DIM = 2; static constexpr uint8_t ATTR_UNDERLINE = 4; static constexpr uint8_t ATTR_REVERSE = 8; struct Cell { uint8_t ch; uint8_t fg; uint8_t bg; uint8_t attr; }; AnsiTerminal(esphome::display::Display *display, esphome::font::Font *font, const char *version) : display_(display), font_(font), version_(version), server_(2323) { clear_buffer_(); cursor_blink_start_ = millis(); } void setup() override { Serial.begin(9600); server_.begin(); ESP_LOGI("ansi_terminal", "TCP SERVER: listening on port 2323"); server_.setNoDelay(true); put_string("\033[2J\033[H"); put_string("\033[38;5;51mESP32 ANSI terminal\033[0m\r\n"); put_string("Version: "); put_string(version_); put_string("\r\n"); put_string("Serial: 9600 TCP: 2323\r\n"); put_string("ANSI 256 colour mode\r\n"); } void loop() override { // Accept a new TCP client whenever one arrives.
NetworkClient incoming = server_.accept(); if (incoming) { if (client_.connected()) client_.stop(); client_ = incoming; client_.setNoDelay(true); telnet_iac_ = false; telnet_cmd_ = 0; telnet_subneg_ = false; suppress_next_lf_ = false; telnet_option_pending_ = false; telnet_option_cmd_ = 0; // Tell the Telnet client that the ESP32 will handle echoing.// This prevents MobaXterm from doing a second local echo.const uint8_t telnet_echo[] = {0xFF, 0xFB, 0x01, 0xFF, 0xFB, 0x03}; client_.write(telnet_echo, sizeof(telnet_echo)); put_string("\033[2J\033[H"); put_string("\033[38;5;51mESP32 ANSI terminal\033[0m"); put_string("\r\n"); put_string("Version: "); put_string(version_); put_string("\r\n"); put_string("Baud: "); char b[16]; snprintf(b, sizeof(b), "%lu", (unsigned long) baud_); put_string(b); put_string("\r\n"); } // TCP -> terminal.
The Telnet negotiation above asks MobaXterm to // disable local echo, so echo characters here from the ESP32.if (client_.connected()) { while (client_.available()) { uint8_t c = (uint8_t) client_.read(); process_tcp_byte(c); if (c == '\r') { const uint8_t crlf[] = {'\r', '\n'}; client_.write(crlf, sizeof(crlf)); } else if (c == '\b' || c == 0x7F) { const uint8_t bs[] = {'\b', ' ', '\b'}; client_.write(bs, sizeof(bs)); } else if (c >= 32 && c <= 126) { client_.write(&c, 1); } } } // Serial -> terminal.Also echo to TCP when connected, so the TCP // terminal can be used as a monitor of the physical serial port.
while (Serial.available()) { uint8_t c = (uint8_t) Serial.read(); process_tcp_byte(c); if (client_.connected()) client_.write(&c, 1); } } void process_tcp_byte(uint8_t c) { // Filter Telnet negotiation bytes first.if (telnet_subneg_) { if (telnet_iac_) { telnet_iac_ = false; if (c == 0xF0) // SE telnet_subneg_ = false; return; } if (c == 0xFF) { telnet_iac_ = true; } return; } if (telnet_option_pending_) { // Consume the option byte belonging to DO/DONT/WILL/WONT.telnet_option_pending_ = false; telnet_option_cmd_ = 0; return; } if (telnet_iac_) { telnet_iac_ = false; if (c == 0xFF) { process_byte(c); return; } if (c == 0xFA) { // SB telnet_subneg_ = true; return; } if (c == 0xFB || c == 0xFC || c == 0xFD || c == 0xFE) { // WILL/WONT/DO/DONT: consume the following option byte.
telnet_option_pending_ = true; telnet_option_cmd_ = c; return; } // Other one-byte Telnet commands (NOP, GA, etc.).return; } if (c == 0xFF) { telnet_iac_ = true; return; } // MobaXterm normally sends CR when Enter is pressed.A terminal // newline needs both horizontal return and vertical movement.
// Generate CR+LF ourselves and suppress the LF if the client sends CRLF.if (c == '\r') { process_byte('\r'); process_byte('\n'); suppress_next_lf_ = true; return; } if (c == '\n') { if (suppress_next_lf_) { suppress_next_lf_ = false; return; } process_byte('\n'); return; } suppress_next_lf_ = false; process_byte(c); } void set_baud(uint32_t baud) { if (baud < 300 || baud > 2000000) return; Serial.flush(); Serial.updateBaudRate(baud); baud_ = baud; put_string("\r\n"); char b[64]; snprintf(b, sizeof(b), "[Serial baud now %lu]\r\n", (unsigned long) baud_); put_string(b); } uint32_t baud() const { return baud_; } void render(esphome::display::Display &it) { for (uint8_t r = 0; r < ROWS; r++) { uint8_t start = 0; while (start < COLS) { const Cell &c0 = cells_[r * COLS + start]; uint8_t fg = c0.fg; uint8_t bg = c0.bg; uint8_t attr = c0.attr; uint8_t end = start + 1; while (end < COLS) { const Cell &c = cells_[r * COLS + end]; if (c.fg != fg || c.bg != bg || c.attr != attr) break; end++; } char text[COLS + 1]; uint8_t n = 0; for (uint8_t x = start; x < end; x++) { uint8_t ch = cells_[r * COLS + x].ch; text[n++] = (ch >= 32 && ch <= 126) ? (char) ch : ' '; } text[n] = 0; Color fgc = ansi_color_(fg); Color bgc = ansi_color_(bg); if (attr & ATTR_REVERSE) { Color t = fgc; fgc = bgc; bgc = t; } if (attr & ATTR_DIM) fgc = dim_(fgc); if (attr & ATTR_BOLD) fgc = brighten_(fgc); it.print(start * CELL_W, r * CELL_H, font_, fgc, text, bgc); if (attr & ATTR_UNDERLINE) { it.horizontal_line(start * CELL_W, r * CELL_H + 7, (end - start) * CELL_W, fgc); } start = end; } } // Draw the terminal cursor.if (cursor_visible_) { bool cursor_on = true; if (cursor_blink_) { cursor_on = ((millis() - cursor_blink_start_) % 1000) < 600; } if (cursor_on && row_ < ROWS && col_ < COLS) { const Cell &c = cells_[row_ * COLS + col_]; // Draw a clearly visible white cursor block.
Color cursor_bg(255, 255, 255); Color cursor_fg(0, 0, 0); it.filled_rectangle( col_ * CELL_W, row_ * CELL_H, CELL_W, CELL_H, cursor_bg); // Draw the character underneath in black.char cursor_char[2]; cursor_char[0] = (c.ch >= 32 && c.ch <= 126) ? c.ch : ' '; cursor_char[1] = 0; it.print( col_ * CELL_W, row_ * CELL_H, font_, cursor_fg, cursor_char); } } } void put_string(const char *s) { while (*s) process_byte((uint8_t) *s++); } private: esphome::display::Display *display_; esphome::font::Font *font_; const char *version_; NetworkServer server_; NetworkClient client_; Cell cells_[COLS * ROWS]; uint8_t row_ = 0; uint8_t col_ = 0; bool cursor_visible_ = true; bool cursor_blink_ = true; uint32_t cursor_blink_start_ = 0; uint8_t fg_ = 7; uint8_t bg_ = 0; uint8_t attr_ = 0; uint8_t saved_row_ = 0; uint8_t saved_col_ = 0; uint8_t saved_fg_ = 7; uint8_t saved_bg_ = 0; uint8_t saved_attr_ = 0; uint8_t state_ = 0; int csi_[8]; uint8_t csi_count_ = 0; int csi_value_ = 0; bool csi_have_value_ = false; uint32_t baud_ = 9600; bool csi_private_ = false; // Minimal Telnet negotiation filter.MobaXterm's Telnet client sends // negotiation bytes immediately after connecting; these must not reach // the ANSI terminal renderer.
bool telnet_iac_ = false; uint8_t telnet_cmd_ = 0; bool telnet_subneg_ = false; bool suppress_next_lf_ = false; bool telnet_option_pending_ = false; uint8_t telnet_option_cmd_ = 0; bool command_mode_ = false; char command_buf_[32]; uint8_t command_len_ = 0; void clear_buffer_() { for (auto &c : cells_) { c.ch = ' '; c.fg = 0; c.bg = 0; c.attr = 0; } } Cell blank_cell_() const { Cell c; c.ch = ' '; c.fg = fg_; c.bg = bg_; c.attr = 0; return c; } void clear_screen_(uint8_t mode) { if (mode == 2 || mode == 3) { clear_buffer_(); row_ = 0; col_ = 0; } else if (mode == 0) { for (uint8_t r = row_; r < ROWS; r++) { uint8_t first = (r == row_) ? col_ : 0; for (uint8_t c = first; c < COLS; c++) cells_[r * COLS + c] = blank_cell_(); } } else if (mode == 1) { for (uint8_t r = 0; r <= row_; r++) { uint8_t last = (r == row_) ? col_ : COLS - 1; for (uint8_t c = 0; c <= last; c++) cells_[r * COLS + c] = blank_cell_(); } } } void clear_line_(uint8_t mode) { if (mode == 2) { for (uint8_t c = 0; c < COLS; c++) cells_[row_ * COLS + c] = blank_cell_(); } else if (mode == 0) { for (uint8_t c = col_; c < COLS; c++) cells_[row_ * COLS + c] = blank_cell_(); } else if (mode == 1) { for (uint8_t c = 0; c <= col_; c++) cells_[row_ * COLS + c] = blank_cell_(); } } void scroll_() { for (uint8_t r = 1; r < ROWS; r++) { for (uint8_t c = 0; c < COLS; c++) cells_[(r - 1) * COLS + c] = cells_[r * COLS + c]; } for (uint8_t c = 0; c < COLS; c++) cells_[(ROWS - 1) * COLS + c] = blank_cell_(); row_ = ROWS - 1; } void newline_() { row_++; if (row_ >= ROWS) scroll_(); } void put_char_(uint8_t ch) { if (col_ >= COLS) { col_ = 0; newline_(); } Cell &c = cells_[row_ * COLS + col_]; c.ch = ch; c.fg = fg_; c.bg = bg_; c.attr = attr_; col_++; if (col_ >= COLS) { col_ = 0; newline_(); } } void handle_command_() { if (!strncmp(command_buf_, "CLEAR", 5)) { clear_buffer_(); row_ = 0; col_ = 0; reset_attributes_(); return; } if (!strncmp(command_buf_, "BAUD?", 5)) { char b[48]; snprintf(b, sizeof(b), "\r\n[Serial baud: %lu]\r\n", (unsigned long) baud_); put_string(b); return; } if (!strncmp(command_buf_, "BAUD ", 5)) { uint32_t new_baud = strtoul(command_buf_ + 5, nullptr, 10); if (new_baud >= 300 && new_baud <= 2000000) { set_baud(new_baud); } else { put_string("\r\n[Invalid baud]\r\n"); } return; } put_string("\r\n[Unknown terminal command]\r\n"); } void process_byte(uint8_t ch) { // Out-of-band commands, accepted over serial OR TCP: // // ~BAUD 19200<CR> // ~BAUD?<CR> // ~CLEAR<CR> if (state_ == 0 && command_mode_) { if (ch == '\r' || ch == '\n') { command_buf_[command_len_] = 0; handle_command_(); command_mode_ = false; command_len_ = 0; return; } if (command_len_ < sizeof(command_buf_) - 1 && ch >= 32 && ch <= 126) { command_buf_[command_len_++] = (char) ch; } else { command_mode_ = false; command_len_ = 0; } return; } if (state_ == 0 && ch == '~' && col_ == 0) { command_mode_ = true; command_len_ = 0; return; } if (state_ == 0) { if (ch == 0x1B) { state_ = 1; return; } if (ch == '\r') { col_ = 0; return; } if (ch == '\n') { newline_(); return; } if (ch == '\b' || ch == 0x7F) { if (col_ > 0) { col_--; cells_[row_ * COLS + col_] = blank_cell_(); } return; } if (ch == '\t') { uint8_t next = (col_ + 8) & 0xF8; while (col_ < next) put_char_(' '); return; } if (ch < 32 || ch == 0x7F) return; put_char_(ch); return; } if (state_ == 1) { if (ch == '[') { state_ = 2; csi_count_ = 0; csi_value_ = 0; csi_have_value_ = false; return; } if (ch == '7') { save_cursor_(); state_ = 0; return; } if (ch == '8') { restore_cursor_(); state_ = 0; return; } if (ch == 'c') { reset_terminal_(); state_ = 0; return; } state_ = 0; return; } // CSI parser.if (ch >= '0' && ch <= '9') { csi_value_ = csi_value_ * 10 + (ch - '0'); csi_have_value_ = true; return; } if (ch == ';') { add_csi_param_(); return; } if (ch == '?') { csi_private_ = true; return; } if (ch == '>' || ch == '!') return; add_csi_param_(); handle_csi_(ch); csi_private_ = false; state_ = 0; } void add_csi_param_() { if (csi_count_ < 8) csi_[csi_count_++] = csi_have_value_ ? csi_value_ : 0; csi_value_ = 0; csi_have_value_ = false; } int p_(uint8_t n, int def = 1) const { if (n >= csi_count_ || csi_[n] == 0) return def; return csi_[n]; } void handle_csi_(char cmd) { switch (cmd) { case 'A': row_ = (row_ > p_(0)) ? row_ - p_(0) : 0; break; case 'B': row_ = min<int>(ROWS - 1, row_ + p_(0)); break; case 'C': col_ = min<int>(COLS - 1, col_ + p_(0)); break; case 'D': col_ = (col_ > p_(0)) ? col_ - p_(0) : 0; break; case 'G': case '`': col_ = min<int>(COLS - 1, max<int>(0, p_(0) - 1)); break; case 'd': row_ = min<int>(ROWS - 1, max<int>(0, p_(0) - 1)); break; case 'H': case 'f': row_ = min<int>(ROWS - 1, max<int>(0, p_(0) - 1)); col_ = min<int>(COLS - 1, max<int>(0, p_(1) - 1)); break; case 'J': clear_screen_(p_(0, 0)); break; case 'K': clear_line_(p_(0, 0)); break; case 's': save_cursor_(); break; case 'u': restore_cursor_(); break; case 'm': sgr_(); break; case 'h': if (csi_private_) { for (uint8_t i = 0; i < csi_count_; i++) { if (csi_[i] == 12) { cursor_blink_ = true; cursor_blink_start_ = millis(); } if (csi_[i] == 25) { cursor_visible_ = true; cursor_blink_start_ = millis(); } } } break; case 'l': if (csi_private_) { for (uint8_t i = 0; i < csi_count_; i++) { if (csi_[i] == 12) cursor_blink_ = false; if (csi_[i] == 25) cursor_visible_ = false; } } break; case 'n': // Not needed for this terminal.break; default: break; } } void sgr_() { if (csi_count_ == 0) { reset_attributes_(); return; } for (uint8_t i = 0; i < csi_count_; i++) { int n = csi_[i]; if (n == 0) { reset_attributes_(); continue; } if (n == 1) { attr_ |= ATTR_BOLD; continue; } if (n == 2) { attr_ |= ATTR_DIM; continue; } if (n == 22) { attr_ &= ~(ATTR_BOLD | ATTR_DIM); continue; } if (n == 4) { attr_ |= ATTR_UNDERLINE; continue; } if (n == 24) { attr_ &= ~ATTR_UNDERLINE; continue; } if (n == 7) { attr_ |= ATTR_REVERSE; continue; } if (n == 27) { attr_ &= ~ATTR_REVERSE; continue; } if (n >= 30 && n <= 37) { fg_ = n - 30; continue; } if (n >= 40 && n <= 47) { bg_ = n - 40; continue; } if (n >= 90 && n <= 97) { fg_ = n - 90 + 8; continue; } if (n >= 100 && n <= 107) { bg_ = n - 100 + 8; continue; } if (n == 39) { fg_ = 7; continue; } if (n == 49) { bg_ = 0; continue; } // 256-colour ANSI: // 38;5;n = foreground // 48;5;n = background if (n == 38 || n == 48) { if (i + 2 < csi_count_ && csi_[i + 1] == 5) { uint8_t colour = constrain(csi_[i + 2], 0, 255); if (n == 38) fg_ = colour; else bg_ = colour; i += 2; } } } } void save_cursor_() { saved_row_ = row_; saved_col_ = col_; saved_fg_ = fg_; saved_bg_ = bg_; saved_attr_ = attr_; } void restore_cursor_() { row_ = saved_row_; col_ = saved_col_; fg_ = saved_fg_; bg_ = saved_bg_; attr_ = saved_attr_; } void reset_attributes_() { fg_ = 7; bg_ = 0; attr_ = 0; } void reset_terminal_() { clear_buffer_(); row_ = 0; col_ = 0; reset_attributes_(); saved_row_ = 0; saved_col_ = 0; state_ = 0; } Color ansi_color_(uint8_t n) const { static const uint8_t base[16][3] = { {0,0,0}, {128,0,0}, {0,128,0}, {128,128,0}, {0,0,128}, {128,0,128}, {0,128,128}, {192,192,192}, {128,128,128}, {255,0,0}, {0,255,0}, {255,255,0}, {0,0,255}, {255,0,255}, {0,255,255}, {255,255,255} }; if (n < 16) return Color(base[n][0], base[n][1], base[n][2]); if (n < 232) { uint8_t v = n - 16; uint8_t r = v / 36; uint8_t g = (v / 6) % 6; uint8_t b = v % 6; static const uint8_t cube[6] = {0, 95, 135, 175, 215, 255}; return Color(cube[r], cube[g], cube[b]); } uint8_t grey = 8 + (n - 232) * 10; return Color(grey, grey, grey); } Color dim_(Color c) const { return Color(c.r / 2, c.g / 2, c.b / 2); } Color brighten_(Color c) const { return Color( min(255, (int)c.r + (255 - c.r) / 4), min(255, (int)c.g + (255 - c.g) / 4), min(255, (int)c.b + (255 - c.b) / 4)); } }; AnsiTerminal *terminal_instance = nullptr; Code Function ESC[0m Reset attributes ESC[1m Bold / brighter ESC[2m Dim ESC[22m Cancel bold/dim ESC[4m Underline ESC[24m Cancel underline ESC[7m Reverse video ESC[27m Cancel reverse ESC[30–37m Standard foreground colours ESC[40–47m Standard background colours ESC[90–97m Bright foreground colours ESC[100–107m Bright background colours ESC[39m Default foreground ESC[49m Default background ESC[38;5;<colour number>m 256-colour foreground ESC[48;5;<colour number>m 256-colour background ESC[2J Clear screen ESC[0J Clear from cursor ESC[1J Clear to cursor ESC[2K Clear whole line ESC[0K Clear from cursor ESC[1K Clear to cursor ESC[nA Cursor up ESC[nB Cursor down ESC[nC Cursor right ESC[nD Cursor left ESC[nG Cursor to column ESC[nd Cursor to row ESC[r;cH Cursor position ESC[r;cf Cursor position ESC[s Save cursor ESC[u Restore cursor ESC7 Save cursor ESC8 Restore cursor ESC[c Reset terminal TAB Move to next 8-column tab stop Backspace Move left and erase character CR Return to column 0 LF Move down one line ESC[?25h Show cursor ESC[?25l Hide cursor ESC[?12h Turn cursor blinking on ESC[?12l Turn cursor blinking off And the 256-colour range is the full 0–255 palette, including the 216-colour cube and 24 greys.
And here is the WebUI in ESPHome…
Read More